Otključajte pravo višenitno programiranje u JavaScriptu. Ovaj sveobuhvatni vodič pokriva SharedArrayBuffer, Atomics, Web Workere i sigurnosne zahtjeve za web aplikacije visokih performansi.
JavaScript SharedArrayBuffer: Dubinski pregled konkurentnog programiranja na webu
Desetljećima je jednonitna priroda JavaScripta bila istovremeno izvor njegove jednostavnosti i značajno usko grlo u performansama. Model petlje događaja (event loop) izvrsno funkcionira za većinu zadataka vođenih korisničkim sučeljem, ali nailazi na poteškoće kada se suoči s računski intenzivnim operacijama. Dugotrajni izračuni mogu zamrznuti preglednik, stvarajući frustrirajuće korisničko iskustvo. Iako su Web Workeri ponudili djelomično rješenje omogućavajući izvođenje skripti u pozadini, dolazili su s vlastitim velikim ograničenjem: neučinkovitom komunikacijom podataka.
Tu nastupa SharedArrayBuffer
(SAB), moćna značajka koja iz temelja mijenja igru uvođenjem pravog, nisko-razinskog dijeljenja memorije između niti na webu. Uparen s objektom Atomics
, SAB otključava novu eru konkurentnih aplikacija visokih performansi izravno u pregledniku. Međutim, s velikom moći dolazi i velika odgovornost—i složenost.
Ovaj vodič će vas provesti kroz dubinski pregled svijeta konkurentnog programiranja u JavaScriptu. Istražit ćemo zašto nam je potrebno, kako SharedArrayBuffer
i Atomics
funkcioniraju, ključna sigurnosna razmatranja koja morate riješiti te praktične primjere kako biste započeli.
Stari svijet: JavaScriptov jednonitni model i njegova ograničenja
Prije nego što možemo cijeniti rješenje, moramo u potpunosti razumjeti problem. Izvršavanje JavaScripta u pregledniku tradicionalno se događa na jednoj niti, često nazivana "glavna nit" ili "UI nit".
Petlja događaja (The Event Loop)
Glavna nit je odgovorna za sve: izvršavanje vašeg JavaScript koda, iscrtavanje stranice, odgovaranje na interakcije korisnika (poput klikova i pomicanja) te pokretanje CSS animacija. Upravlja tim zadacima koristeći petlju događaja, koja neprestano obrađuje red poruka (zadataka). Ako zadatak traje dugo, blokira cijeli red. Ništa se drugo ne može dogoditi—korisničko sučelje se zamrzava, animacije trzaju, a stranica postaje neodzivna.
Web Workeri: Korak u pravom smjeru
Web Workeri su uvedeni kako bi se ublažio ovaj problem. Web Worker je u suštini skripta koja se izvodi na zasebnoj pozadinskoj niti. Možete prebaciti teške izračune na workera, oslobađajući glavnu nit da se bavi korisničkim sučeljem.
Komunikacija između glavne niti i workera odvija se putem postMessage()
API-ja. Kada šaljete podatke, oni se obrađuju algoritmom strukturiranog kloniranja. To znači da se podaci serijaliziraju, kopiraju, a zatim deserijaliziraju u kontekstu workera. Iako je učinkovit, ovaj proces ima značajne nedostatke za velike skupove podataka:
- Pad performansi: Kopiranje megabajta ili čak gigabajta podataka između niti je sporo i intenzivno za procesor.
- Potrošnja memorije: Stvara se duplikat podataka u memoriji, što može biti veliki problem za uređaje s ograničenom memorijom.
Zamislite uređivač videozapisa u pregledniku. Slanje cijelog video okvira (koji može biti nekoliko megabajta) naprijed-natrag workeru za obradu 60 puta u sekundi bilo bi prohibitivno skupo. To je točno problem koji je SharedArrayBuffer
dizajniran da riješi.
Mijenjač pravila igre: Uvod u SharedArrayBuffer
SharedArrayBuffer
je spremnik sirovih binarnih podataka fiksne duljine, sličan ArrayBufferu
. Ključna razlika je u tome što se SharedArrayBuffer
može dijeliti između više niti (npr. glavne niti i jednog ili više Web Workera). Kada "šaljete" SharedArrayBuffer
koristeći postMessage()
, ne šaljete kopiju; šaljete referencu na isti blok memorije.
To znači da su sve promjene napravljene na podacima u spremniku od strane jedne niti trenutno vidljive svim ostalim nitima koje imaju referencu na njega. To eliminira skupi korak kopiranja i serijalizacije, omogućujući gotovo trenutno dijeljenje podataka.
Razmišljajte o tome ovako:
- Web Workeri s
postMessage()
: To je kao da dvoje kolega radi na dokumentu slanjem kopija e-poštom naprijed-natrag. Svaka promjena zahtijeva slanje potpuno nove kopije. - Web Workeri s
SharedArrayBuffer
: To je kao da dvoje kolega radi na istom dokumentu u zajedničkom mrežnom uređivaču (poput Google Docsa). Promjene su vidljive oboma u stvarnom vremenu.
Opasnost dijeljene memorije: Utrkivanje (Race Conditions)
Trenutno dijeljenje memorije je moćno, ali također uvodi klasičan problem iz svijeta konkurentnog programiranja: utrkivanje (race conditions).
Stanje utrke događa se kada više niti pokušava istovremeno pristupiti i mijenjati iste dijeljene podatke, a konačni ishod ovisi o nepredvidivom redoslijedu kojim se izvršavaju. Razmotrite jednostavan brojač pohranjen u SharedArrayBufferu
. I glavna nit i worker ga žele povećati.
- Nit A čita trenutnu vrijednost, koja je 5.
- Prije nego što Nit A može zapisati novu vrijednost, operativni sustav je pauzira i prebacuje se na Nit B.
- Nit B čita trenutnu vrijednost, koja je još uvijek 5.
- Nit B izračunava novu vrijednost (6) i zapisuje je natrag u memoriju.
- Sustav se vraća na Nit A. Ona ne zna da je Nit B išta učinila. Nastavlja odakle je stala, izračunavajući svoju novu vrijednost (5 + 1 = 6) i zapisujući 6 natrag u memoriju.
Iako je brojač povećan dva puta, konačna vrijednost je 6, a ne 7. Operacije nisu bile atomske—bile su prekidive, što je dovelo do gubitka podataka. To je točno razlog zašto ne možete koristiti SharedArrayBuffer
bez njegovog ključnog partnera: objekta Atomics
.
Čuvar dijeljene memorije: Objekt Atomics
Objekt Atomics
pruža skup statičkih metoda za izvođenje atomskih operacija na objektima SharedArrayBuffer
. Atomska operacija zajamčeno se izvršava u cijelosti bez prekida od strane bilo koje druge operacije. Ili se dogodi u potpunosti ili se ne dogodi uopće.
Korištenje Atomics
objekta sprječava utrkivanje osiguravajući da se operacije čitanja-mijenjanja-pisanja na dijeljenoj memoriji izvode sigurno.
Ključne metode objekta Atomics
Pogledajmo neke od najvažnijih metoda koje pruža Atomics
.
Atomics.load(typedArray, index)
: Atomski čita vrijednost na zadanom indeksu i vraća je. To osigurava da čitate potpunu, neoštećenu vrijednost.Atomics.store(typedArray, index, value)
: Atomski pohranjuje vrijednost na zadanom indeksu i vraća tu vrijednost. To osigurava da operacija pisanja nije prekinuta.Atomics.add(typedArray, index, value)
: Atomski dodaje vrijednost vrijednosti na zadanom indeksu. Vraća izvornu vrijednost na toj poziciji. Ovo je atomski ekvivalentx += value
.Atomics.sub(typedArray, index, value)
: Atomski oduzima vrijednost od vrijednosti na zadanom indeksu.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Ovo je moćno uvjetno pisanje. Provjerava je li vrijednost naindex
jednakaexpectedValue
. Ako jest, zamjenjuje je sreplacementValue
i vraća izvornuexpectedValue
. Ako nije, ne radi ništa i vraća trenutnu vrijednost. Ovo je temeljni gradivni blok za implementaciju složenijih sinkronizacijskih primitiva poput zaključavanja (locks).
Sinkronizacija: Iznad jednostavnih operacija
Ponekad trebate više od sigurnog čitanja i pisanja. Trebate da se niti koordiniraju i čekaju jedna na drugu. Uobičajeni antipattern je "aktivno čekanje", gdje nit sjedi u uskoj petlji, neprestano provjeravajući memorijsku lokaciju za promjenom. To troši cikluse procesora i crpi bateriju.
Atomics
pruža mnogo učinkovitije rješenje s wait()
i notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Ovo govori niti da ode u stanje mirovanja. Provjerava je li vrijednost naindex
još uvijekvalue
. Ako jest, nit spava dok je ne probudiAtomics.notify()
ili dok ne istekne opcionalnitimeout
(u milisekundama). Ako se vrijednost naindex
već promijenila, odmah se vraća. Ovo je nevjerojatno učinkovito jer nit u mirovanju troši gotovo nikakve resurse procesora.Atomics.notify(typedArray, index, count)
: Ovo se koristi za buđenje niti koje spavaju na određenoj memorijskoj lokaciji putemAtomics.wait()
. Probudit će najvišecount
niti koje čekaju (ili sve akocount
nije naveden ili jeInfinity
).
Sve zajedno: Praktični vodič
Sada kada razumijemo teoriju, prođimo kroz korake implementacije rješenja koristeći SharedArrayBuffer
.
Korak 1: Sigurnosni preduvjet - Izolacija unakrsnog podrijetla
Ovo je najčešća prepreka za programere. Iz sigurnosnih razloga, SharedArrayBuffer
je dostupan samo na stranicama koje su u stanju izolacije unakrsnog podrijetla. Ovo je sigurnosna mjera za ublažavanje ranjivosti spekulativnog izvršavanja poput Spectre, koje bi potencijalno mogle koristiti tajmere visoke rezolucije (omogućene dijeljenom memorijom) za curenje podataka između podrijetla.
Da biste omogućili izolaciju unakrsnog podrijetla, morate konfigurirati svoj web poslužitelj da šalje dva specifična HTTP zaglavlja za vaš glavni dokument:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Izolira kontekst pregledavanja vašeg dokumenta od drugih dokumenata, sprječavajući ih da izravno komuniciraju s vašim prozorom (window object).Cross-Origin-Embedder-Policy: require-corp
(COEP): Zahtijeva da svi podresursi (poput slika, skripti i iframeova) koje vaša stranica učitava moraju biti ili s istog podrijetla ili izričito označeni kao učitljivi s drugog podrijetla pomoću zaglavljaCross-Origin-Resource-Policy
ili CORS-a.
Ovo može biti izazovno za postaviti, pogotovo ako se oslanjate na skripte ili resurse trećih strana koji ne pružaju potrebna zaglavlja. Nakon konfiguriranja poslužitelja, možete provjeriti je li vaša stranica izolirana provjerom svojstva self.crossOriginIsolated
u konzoli preglednika. Mora biti true
.
Korak 2: Stvaranje i dijeljenje spremnika
U vašoj glavnoj skripti stvarate SharedArrayBuffer
i "pogled" na njega koristeći TypedArray
poput Int32Array
.
main.js:
// Prvo provjerite izolaciju unakrsnog podrijetla!
if (!self.crossOriginIsolated) {
console.error("Ova stranica nije izolirana unakrsnog podrijetla. SharedArrayBuffer neće biti dostupan.");
} else {
// Stvorite dijeljeni spremnik za jedan 32-bitni cijeli broj.
const buffer = new SharedArrayBuffer(4);
// Stvorite pogled na spremnik. Sve atomske operacije događaju se na pogledu.
const int32Array = new Int32Array(buffer);
// Inicijalizirajte vrijednost na indeksu 0.
int32Array[0] = 0;
// Stvorite novog workera.
const worker = new Worker('worker.js');
// Pošaljite DIJELJENI spremnik workeru. Ovo je prijenos reference, ne kopija.
worker.postMessage({ buffer });
// Slušajte poruke od workera.
worker.onmessage = (event) => {
console.log(`Worker je izvijestio o završetku. Konačna vrijednost: ${Atomics.load(int32Array, 0)}`);
};
}
Korak 3: Izvođenje atomskih operacija u Workeru
Worker prima spremnik i sada može na njemu izvoditi atomske operacije.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker je primio dijeljeni spremnik.");
// Izvršimo neke atomske operacije.
for (let i = 0; i < 1000000; i++) {
// Sigurno povećajmo dijeljenu vrijednost.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker je završio s povećavanjem.");
// Signalizirajmo glavnoj niti da smo gotovi.
self.postMessage({ done: true });
};
Korak 4: Napredniji primjer - Paralelno zbrajanje sa sinkronizacijom
Riješimo realističniji problem: zbrajanje vrlo velikog niza brojeva koristeći više workera. Koristit ćemo Atomics.wait()
i Atomics.notify()
za učinkovitu sinkronizaciju.
Naš dijeljeni spremnik imat će tri dijela:
- Indeks 0: Zastavica statusa (0 = obrađuje se, 1 = završeno).
- Indeks 1: Brojač završenih workera.
- Indeks 2: Konačni zbroj.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// Koristimo dva 32-bitna cijela broja za rezultat kako bismo izbjegli prelijevanje za velike zbrojeve.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 cijela broja
const sharedArray = new Int32Array(sharedBuffer);
// Generirajte neke nasumične podatke za obradu
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// Stvorite nedijeljeni pogled za dio podataka workera
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Ovo se kopira
});
}
console.log('Glavna nit sada čeka da workeri završe...');
// Čekaj da zastavica statusa na indeksu 0 postane 1
// Ovo je puno bolje od while petlje!
Atomics.wait(sharedArray, 0, 0); // Čekaj ako je sharedArray[0] jednak 0
console.log('Glavna nit je probuđena!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Konačni paralelni zbroj je: ${finalSum}`);
} else {
console.error('Stranica nije izolirana unakrsnog podrijetla.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Izračunaj zbroj za dio podataka ovog workera
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Atomski dodaj lokalni zbroj u dijeljeni ukupni zbroj
Atomics.add(sharedArray, 2, localSum);
// Atomski povećaj brojač 'završenih workera'
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Ako je ovo posljednji worker koji je završio...
const NUM_WORKERS = 4; // U pravoj aplikaciji bi se trebalo proslijediti
if (finishedCount === NUM_WORKERS) {
console.log('Posljednji worker je završio. Obavještavam glavnu nit.');
// 1. Postavi zastavicu statusa na 1 (završeno)
Atomics.store(sharedArray, 0, 1);
// 2. Obavijesti glavnu nit, koja čeka na indeksu 0
Atomics.notify(sharedArray, 0, 1);
}
};
Primjeri upotrebe i primjene u stvarnom svijetu
Gdje ova moćna, ali složena tehnologija zapravo čini razliku? Izvrsna je u aplikacijama koje zahtijevaju teško, paralelizabilno računanje na velikim skupovima podataka.
- WebAssembly (Wasm): Ovo je ključni slučaj upotrebe. Jezici poput C++, Rusta i Goa imaju zrelu podršku za višenitno programiranje. Wasm omogućuje programerima da kompiliraju postojeće višenitne aplikacije visokih performansi (poput pokretača igara, CAD softvera i znanstvenih modela) za izvođenje u pregledniku, koristeći
SharedArrayBuffer
kao temeljni mehanizam za komunikaciju između niti. - Obrada podataka u pregledniku: Vizualizacija podataka velikih razmjera, inferencija modela strojnog učenja na strani klijenta i znanstvene simulacije koje obrađuju ogromne količine podataka mogu se značajno ubrzati.
- Uređivanje medija: Primjena filtara na slike visoke rezolucije ili obrada zvuka na zvučnoj datoteci može se podijeliti na dijelove i paralelno obrađivati od strane više workera, pružajući korisniku povratne informacije u stvarnom vremenu.
- Igranje visokih performansi: Moderni pokretači igara uvelike se oslanjaju na višenitno programiranje za fiziku, umjetnu inteligenciju i učitavanje resursa.
SharedArrayBuffer
omogućuje izradu igara kvalitete konzola koje se u potpunosti izvode u pregledniku.
Izazovi i završna razmatranja
Iako je SharedArrayBuffer
transformativan, nije čarobno rješenje. To je nisko-razinski alat koji zahtijeva pažljivo rukovanje.
- Složenost: Konkurentno programiranje je notorno teško. Ispravljanje grešaka poput utrkivanja i mrtvih petlji (deadlocks) može biti nevjerojatno izazovno. Morate razmišljati drugačije o tome kako se upravlja stanjem vaše aplikacije.
- Mrtve petlje (Deadlocks): Mrtva petlja nastaje kada su dvije ili više niti zauvijek blokirane, svaka čekajući da druga oslobodi resurs. To se može dogoditi ako neispravno implementirate složene mehanizme zaključavanja.
- Sigurnosni troškovi: Zahtjev za izolacijom unakrsnog podrijetla značajna je prepreka. Može poremetiti integracije s uslugama trećih strana, oglasima i platnim prolazima ako ne podržavaju potrebna CORS/CORP zaglavlja.
- Nije za svaki problem: Za jednostavne pozadinske zadatke ili I/O operacije, tradicionalni model Web Workera s
postMessage()
često je jednostavniji i dovoljan. Posegnite zaSharedArrayBuffer
samo kada imate jasan, procesorski vezan problem koji uključuje velike količine podataka.
Zaključak
SharedArrayBuffer
, u kombinaciji s Atomics
i Web Workerima, predstavlja promjenu paradigme za web razvoj. On ruši granice jednonitnog modela, pozivajući novu klasu moćnih, performansnih i složenih aplikacija u preglednik. Postavlja web platformu na ravnopravniju osnovu s razvojem nativnih aplikacija za računski intenzivne zadatke.
Putovanje u konkurentni JavaScript je izazovno i zahtijeva rigorozan pristup upravljanju stanjem, sinkronizaciji i sigurnosti. Ali za programere koji žele pomaknuti granice onoga što je moguće na webu—od sinteze zvuka u stvarnom vremenu do složenog 3D iscrtavanja i znanstvenog računarstva—ovladavanje SharedArrayBufferom
više nije samo opcija; to je ključna vještina za izgradnju sljedeće generacije web aplikacija.